/*******************************************************************************
 * Copyright © 2012 Ben Swim
 *
 * This file is part of ImoSPC.
 *
 * ImoSPC is free software: you can redistribute it and/or modify it under the
 * terms of the GNU General Public License as published by the Free Software
 * Foundation, either version 3 of the License, or (at your option) any later
 * version.
 *
 * ImoSPC is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * ImoSPC. If not, see <http://www.gnu.org/licenses/>.
 *******************************************************************************/
package net.smwcentral;

import java.awt.*;
import java.io.*;
import java.net.*;
import java.nio.*;
import java.nio.charset.*;
import java.util.*;
import java.util.concurrent.atomic.*;
import java.util.zip.*;
import javax.sound.sampled.*;

import netscape.javascript.*;

import java.applet.Applet;
import java.security.AccessController;
import java.security.PrivilegedAction;
import javax.swing.JOptionPane;
import javax.swing.SwingUtilities;

// A reference to an integer.
final class RefInt {
	public int value;
}

// Manages playback.
final class SpcPlayerImpl implements Runnable {
	// The SPC player's own private lock.
	private final Object m_lock = new Object();
	private final ImoSPC m_owner;
	
	private Thread m_thread;
	private AtomicBoolean m_isPlaying = new AtomicBoolean(false);
	
	private final int m_prefferedSampleRate = 32000;
	private int m_sampleRate;
	private AudioFormat m_format;
	private SourceDataLine m_line;
	private DataLine.Info m_info;
	private SpcEmu m_emu;
	
	// Assigns an owner to this class.
	public SpcPlayerImpl(ImoSPC owner) {
		m_owner = owner;
	}
	
	private void getSpcFileLength(byte[] spcData) {
		RefInt start = new RefInt(), length = new RefInt();
		DataHelper.getSpcFileLength(spcData, start, length);
		m_emu.setFadeMs(start.value, length.value);
	}
	
	// Load a file.
	public void setFile(byte[] spcData, boolean stop) throws Exception {
		synchronized(m_lock) {
			// Stop playing before changing the file.
			stop(stop);
			
			m_emu = new SpcEmu();

			// Make sure the format and data line info is set.
			int newSampleRate = m_emu.setSampleRate(m_prefferedSampleRate);
			if (m_format == null ||
				m_info == null ||
				m_sampleRate != newSampleRate) {
					m_sampleRate = newSampleRate;
					m_format = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
									m_sampleRate, 16, 2, 4, m_sampleRate, true);
					m_info = new DataLine.Info(SourceDataLine.class, m_format);
			}
			
			m_emu.loadFile(spcData);
			m_emu.startTrack(0);
			getSpcFileLength(spcData);
		}
	}
	
	// Begin/resume playback
	public void play() throws Exception {
		synchronized(m_lock) {
			// Make sure there is a data line to write to.
			if (m_line == null || !m_line.isOpen()) {
				m_line = (SourceDataLine)AudioSystem.getLine(m_info);
				m_line.open(m_format);
			}
			m_thread = new Thread(this);
			m_isPlaying.set(true);
			m_thread.start();
		}
	}
	
	// Stop playing. Returns true if this call is what made playback stop.
	public boolean pause() throws Exception {
		if (m_thread != null) {
			synchronized(m_lock) {
				if (m_thread != null && m_thread.isAlive()) {
					boolean result = m_isPlaying.getAndSet(false);
					m_thread.join();
					m_thread = null;
					return result;
				}
			}
		}
		return false;
	}
	
	// Stops playing and closes the current data line.
	public void stop(boolean callback) throws Exception {
		if (m_line != null) {
			synchronized(m_lock) {
				if (m_line != null) {
					if ((pause() || m_line.isOpen()) && callback)
						m_owner.onStop();
					
					m_line.close();
					m_line = null;
				}
			}
		}
	}
	public void stop() throws Exception {
		stop(true);
	}
	
	// Checks if the player is still playing.
	public boolean isPlaying() { return m_isPlaying.get(); }
	
	// Play data on the stream.
	@Override
	public void run() {
		// Call onPlay first.
		m_owner.onPlay();
		
		// Keep a local copy of m_line
		SourceDataLine line = m_line;
		
		byte[] buffer = new byte[8192];
		line.start();
		
		try {
			// Continue looping until until the track ends or pause()/stop() get
			// called.
			while (m_isPlaying.get() && !m_emu.trackEnded()) {
				int count = m_emu.play(buffer, buffer.length / 2);
				line.write(buffer, 0, count * 2);
			}
		} finally {
			// Do this stuff even if an exception gets thrown.
			
			// Don't close the line here in case pause() was called, in which case
			// the line will need to be able to start up normally.
			line.stop();
			// If this code gets reached because m_emu.trackEnded() returned false,
			// then stop() wasn't called, so call m_owner.onStop().
			if (m_isPlaying.getAndSet(false)) {
				m_owner.onStop();

				line.close();
			}
		}
	}
}

final class DataHelper {
	// Do NOT create instances of DataHelper!
	private DataHelper() {}

	private static KeyedCache s_cache;
	
	// Clear the global cache.
	static void clearGlobalCache() { s_cache.clear(); }
	
	// Reads a stream as a byte array, large enough to contain an SPC file.
	public static byte[] readSPC(InputStream stream) throws IOException {
		// A 64.5 KiB array
		byte[] data = new byte[66048];
		
		int length = 0;
		int count;
		while (length < data.length &&
				(count = stream.read(data, length, data.length - length)) != -1)
			length += count;
		
		// Fill the rest of the array with 0's
		if (data.length > length)
			Arrays.fill(data, length, data.length, (byte)0);
		
		return data;
	}
	
	// Loads a URL and returns the targeted file as a byte array.
	public static CacheEntry readSPC(String url, URLConnection con)
			throws IOException {
		byte[] result = null;
		
		InputStream stream = con.getInputStream();
		try { result = readSPC(stream); }
		finally { stream.close(); }
		
		return new CacheEntry(url, result,
			new FixedMap<String, byte[]>(url, result));
	}
	
	// Checks if a file has the specified extension.
	// The efficient way. No objects created.
	public static boolean isEqualExt(String file, String ext) {
		if (file.length() <= ext.length() + 1)
			return false;
		
		// Make sure there is a period in the correct spot
		if (file.charAt(file.length() - ext.length() - 1) != '.')
			return false;
		
		// Enumerate from the end to the start.
		// The beginning of ext should always be reached first.
		for (int i = file.length() - 1, j = ext.length() - 1; j >= 0; i--, j--)
			if (Character.toLowerCase(file.charAt(i)) != ext.charAt(j))
				return false;
		
		return true;
	}
	
	// Checks if a file has the extension .spc or .sp[n], where [n] is a number
	// between 0 and 9.
	public static boolean isSPCExt(String file) {
		if (file.length() <= 4)
			return false;
		
		// Make sure there is a period in the correct spot
		if (file.charAt(file.length() - 4) != '.' ||
			Character.toLowerCase(file.charAt(file.length() - 3)) != 's' ||
			Character.toLowerCase(file.charAt(file.length() - 2)) != 'p')
			return false;
		
		char c = file.charAt(file.length() - 1);
		return c == 'c' || c == 'C' || ((c >= '0') && (c <= '9'));
	}
	
	// Opens a Zip file and prompts the user for which file should be played, if
	// necessary.
	public static CacheEntry readZip(String url, URLConnection con,
			boolean autoSt) throws Exception {
		ArrayList<Map.Entry<String, byte[]>> entries =
				new ArrayList<Map.Entry<String, byte[]>>();
			
		byte[] last = null;
		
		InputStream s = con.getInputStream();
		try {
			ZipInputStream stream = new ZipInputStream(s);
			s = stream;
			// Find every .SPC file in the Zip file.
			for (ZipEntry entry; (entry = stream.getNextEntry()) != null;)
				if (!entry.isDirectory()) {
					String n = entry.getName();
					if (isSPCExt(n))
						//entries.put(n, last = readSPC(stream));
						entries.add(new AbstractMap
							.SimpleImmutableEntry<String, byte[]>('/' + n,
											last = readSPC(stream)));
				}
			if (entries.isEmpty())
				// If no entries were found, throw an exception.
				throw new Exception("The specified Zip file contains " +
									"no SPC files.");
			// Cache the archive.
			return new CacheEntry(url, last,
					new FixedMap<String, byte[]>(entries));
		} finally {
			// Close the stream.
			s.close();
		}
	}
	
	public static NavigableMapIterator<String, byte[]> getIterator(String title,
			CacheEntry entry, boolean autoSelect, URL url, Component parent,
			int index) throws Exception {
		
		if (!autoSelect && entry.entries.size() > 1) {
			// If multiple entries were found, prompt the user to select one
			return FilePicker.promptForSPCFileToOpen(parent, entry.entries,
				// Use the filename if there is no title
				(title == null || title.isEmpty()) ? url.getFile() : title);
		} else {
			// Return an iterator positioned at the first entry.
			return entry.entries.iterator(index >= 0 ? index : 0);
		}
	}
	
	// Initialize the global cache, if it wasn't done already.
	public synchronized static void initGlobalCache(int size) {
		if (s_cache == null)
			s_cache = new KeyedCache(size);
	}
	
	// Transform ASCII digits to an actual number.
	private static int parseText(byte[] b, int start, int length) {
		final int end = start + length;
		
		// 5 digits gives the range 0..99,999; 0x0001869F.
		// That range is small enough to use a 32-bit signed integer.
		int result = 0;
		
		for (int i = start; i < end; i++) {
			// No need to verify b[i] because getSpcFileLength will only call
			// this function if all bytes are either NUL or in the '0'..'9'
			// ASCII range.
			if (b[i] != 0) result = (result * 10) + (b[i] - '0');
		}
		
		return result;
	}
	
	// Transform bytes to an actual number, using little-endian byte ordering.
	private static int parseBinary(byte[] b, int start, int length) {
		final int end = start + length;
		int result = 0;
		int offs = 0;
		
		// Parse an integer as little endian.
		for (int i = start; i < end; i++) {
			result |= (b[i] & 0xFF) << offs;
			offs += 8;
		}
		
		return result;
	}
	
	// Get how long to play the SPC file before fading out.
	public static void getSpcFileLength(byte[] spcData, RefInt start, RefInt length) {
		int s = 0, len = 0;
		boolean isText = true;
		
		// Ckeck to see if any bytes are out of the '0' to '9' ASCII range.
		// If there are, then the ID666 tags are in binary format.
		for (int i = 0xA9; i < 0xB1; i++) {
			byte v = spcData[i];
			if (v != 0 && (v < '0' || v > '9')) {
				isText = false;
				break;
			}
		}
		
		if (isText) {
			// Parse text ID666 tags
			s = parseText(spcData, 0xA9, 3);	// 0xA9, 3 digits
			len = parseText(spcData, 0xAC, 5);	// 0xAC, 5 digits
		} else {
			// Parse binary ID666 tags
			s = parseBinary(spcData, 0xA9, 3);	// 0xA9, 3 bytes
			len = parseBinary(spcData, 0xAC, 4);// 0xAC, 4 bytes
		}
		
		// If the fade start is 0, then use 2 minutes instead.
		if (s <= 0)
			s = 120;

		start.value = s;
		length.value = len;
	}
	
	// Gets the 32-byte title stored at 0x002E in an SPC file.
	public static String getSpcTitle(byte[] spc) {
		CharsetDecoder decoder = Charset.defaultCharset().newDecoder();
		decoder.onMalformedInput(CodingErrorAction.REPORT);
		decoder.onUnmappableCharacter(CodingErrorAction.REPLACE);
		try {
			int i;
			for (i = 0; i < 32; i++) {
				if (spc[i + 0x2E] == 0) break;
			}
			return decoder.decode(ByteBuffer.wrap(spc, 0x2E, i)).toString();
		} catch (Exception e) {
			e.printStackTrace();
			return null;
		}
	}
	
	// Attempts to determine what exactly a URL is, based on its Content-Type
	// header, or its extension if the content type is unknown/unspecified.
	public static NavigableMapIterator<String, byte[]> readURL(URL url,
			Component comp, String title, KeyedCache localCache, boolean autoSt,
			int index) throws IOException, Exception {
		String strUrl = url.toString();
		CacheEntry cached = null;
		if ((cached = localCache.get(strUrl)) != null &&
			(cached.entries != null)) {
			System.out.printf("Loading locally cached item: \"%s\"%n", strUrl);
		} else if ((cached = s_cache.get(strUrl)) != null &&
				   (cached.entries != null)) {
			System.out.printf("Loading globally cached item: \"%s\"%n", strUrl);
			localCache.put(cached); // Add to local cache.
		} else {
			URLConnection connection = url.openConnection();
			connection.setUseCaches(true);
			
			String contentType = connection.getContentType();
			
			// Treat an empty Content-Type header as unspecified.
			// Also, Java uses content/unknown for unknown file types.
			if (contentType != null &&
				((contentType = contentType.trim()).isEmpty() ||
				contentType.equals("content/unknown") ||
				contentType.equals("application/unknown") ||
				contentType.equals("application/octet-stream")))
					contentType = null;
			
			// Try to use the Content-Type HTTP header, if possible.
			if (contentType != null) {
				if (contentType.equals("audio/x-spc"))
					cached = readSPC(strUrl, connection);
				else if (contentType.equals("application/zip"))
					cached = readZip(strUrl, connection, autoSt);
				else
					System.err.printf("Unknown content type: %s%n" +
									  "Extension will be detected instead.%n",
									  contentType);
			// Otherwise, try to determine the type of file by the extension.
			} if (cached == null) {
				if (isSPCExt(strUrl))
					cached = readSPC(strUrl, connection);
				else
					cached = readZip(strUrl, connection, autoSt);
			}
			
			s_cache.put(localCache.put(cached));
		}
		
		return getIterator(title, cached, autoSt, url, comp, index);
	}
}

/**
 * This {@code ImoSPC} is an invisible {@code Applet} that plays SPC files,
 * in addition to extracting them from Zip files.
 * 
 * @author ShadowFan-X
 */
public final class ImoSPC extends Applet {
	private static final Object s_globalLock = new Object();
	private static ImoSPC s_lastPlayer;
	
	private final Object m_lock = new Object();
	private final Object m_archLock = new Object();
	private final Object m_playerLock = new Object();
	
	private String m_startCallback;
	private String m_pauseCallback;
	private String m_stopCallback;
	private String m_startFailCallback;
	private String m_newSourceCallback;
	
	private boolean m_autoAdvance;
	private boolean m_autoSelect;
	private boolean m_tabFriendlyMode;
	
	private SpcPlayerImpl m_player;
	private String m_loadedUrl;
	private Object m_userData;
	
	private KeyedCache m_cache;
	
	private NavigableIterator<Map.Entry<String, byte[]>> m_archive;
	
	private SpcDesc m_descCur;
	private SpcDesc m_descNext;
	private SpcDesc m_descPrev;
	private SpcDesc m_descFirst;
	private SpcDesc m_descLast;
	
	// Get a parameter as an integer; return the specified value if it fails.
	private int getParameterInt(String name, int defaultValue, int max, int min) {
		String result = getParameter(name);
		if (result == null || result.isEmpty())
			return defaultValue;
		try {
			int i = Integer.parseInt(result);
			if (i <= min) return min;
			if (i >= max) return max;
			return i;
		} catch (Exception e) { return defaultValue; }
	}
	// Get a parameter as a boolean.
	private boolean getParameterBool(String name, boolean defaultValue) {
		String result = getParameter(name);
		if (result == null || (result = result.trim()).isEmpty())
			return defaultValue;
		
		result = result.toUpperCase(Locale.ENGLISH);
		// True/Yes
		if (result.equals("TRUE") ||
			result.equals("YES")) return true;
		// Other values to check for are False/No; these are the most likely
		// candidates.
		if (result.equals("FALSE") ||
			result.equals("NO")) return false;
		
		try {
			// Nonzero integers
			return Integer.parseInt(result) != 0;
		} catch (Exception e) {
			try {
				// Nonzero, numeric floating point values
				float f = Float.parseFloat(result);
				return !Float.isNaN(f) && f != 0;
			} catch (Exception e2) {
				// All other values are invalid.
				return defaultValue;
			}
		}
	}
	
	/**
	 * Initializes the {@code ImoSPC} so it can play SPC files.
	 */
	@Override
	public void init() {
		if (m_player != null) return;
		
		synchronized(m_lock) {
			try {
				for (javax.swing.UIManager.LookAndFeelInfo info :
						javax.swing.UIManager.getInstalledLookAndFeels()) {
					if ("Nimbus".equals(info.getName())) {
						javax.swing.UIManager.setLookAndFeel(info.getClassName());
						break;
					}
				}
			} catch (Exception ex) {
				ex.printStackTrace();
			}
			
			m_startCallback = getParameter("onPlay");
			if (m_startCallback != null &&
				m_startCallback.isEmpty())
					m_startCallback = null;
			m_pauseCallback = getParameter("onPause");
			if (m_pauseCallback != null &&
				m_pauseCallback.isEmpty())
					m_pauseCallback = null;
			m_stopCallback = getParameter("onStop");
			if (m_stopCallback != null &&
				m_stopCallback.isEmpty())
					m_pauseCallback = null;
			m_startFailCallback = getParameter("onPlayFailure");
			if (m_startFailCallback != null &&
				m_startFailCallback.isEmpty())
					m_startFailCallback = null;
			m_newSourceCallback = getParameter("onNewSource");
			if (m_newSourceCallback != null &&
				m_newSourceCallback.isEmpty())
					m_newSourceCallback = null;

			m_player = new SpcPlayerImpl(this);
			
			DataHelper.initGlobalCache(getParameterInt("globalCacheSize", 8, 20, 0));
			m_cache = new KeyedCache(getParameterInt("appletCacheSize", 3, 20, 0));
			
			// If autoAdvance is set to true, then when one track finishes, the
			// next track will start playing. The onStop callback will not be
			// called until the last track has stopped.
			m_autoAdvance = getParameterBool("autoAdvance", false);
			// If autoSelect is set to true, then when an archive with more than
			// one SPC in it is played, the first one will start playing
			// automatically.
			m_autoSelect = getParameterBool("autoSelect", false);
			// If multiTabFriendly is set to true, then the current applet will
			// be kept as a global variable so it can be stopped when a file is
			// played using a different applet.
			m_tabFriendlyMode = getParameterBool("multiTabFriendly", true);
			
			// onLoad is a parameter that has Javascript that should be called
			// after ImoSPC is ready. This typically allows a webpage to hide
			// any play/pause/stop buttons until after ImoSPC is ready; if init
			// fails, then the buttons will never appear.
			String loaded = getParameter("onLoad");
			if (loaded != null && !loaded.isEmpty()) {
				try {
					JSObject window = JSObject.getWindow(this);
					window.eval(loaded);
				} catch (Exception e) { e.printStackTrace(); }
			}
		}
	}

	/**
	 * Stops all playback.
	 */
	@Override
	public void stop() {
		doStopFile();
	}

	/**
	 * Destroys the player. Set all fields to null, effectively making them
	 * eligible for garbage collection.
	 */
	@Override
	public void destroy() {
		if (m_tabFriendlyMode && s_lastPlayer == this) {
			synchronized(s_globalLock) {
				// Prevent possible null reference exceptions
				if (s_lastPlayer == this) {
					s_lastPlayer = null;
				}
			}
		}
		synchronized(m_lock) {
			doStopFileImpl();

			m_player = null;
			m_loadedUrl = null;
			m_userData = null;
		}
	}
	
	// Callback IDs.
	private final int CALLBACK_ONPLAY		= 1;
	private final int CALLBACK_ONPAUSE		= 2;
	private final int CALLBACK_ONSTOP		= 3;
	private final int CALLBACK_ONPLAYFAIL	= 4;
	private final int CALLBACK_ONNEWSOURCE	= 5;
	
	private void doCallback(int n, Object userdata) {
		String callback;
		JSObject window;
		
		try {
			synchronized(m_lock) {
				switch (n) {
					case CALLBACK_ONPLAY:		callback = m_startCallback; break;
					case CALLBACK_ONPAUSE:		callback = m_pauseCallback; break;
					case CALLBACK_ONSTOP:		callback = m_stopCallback; break;
					case CALLBACK_ONPLAYFAIL:	callback = m_startFailCallback; break;
					case CALLBACK_ONNEWSOURCE:	callback = m_newSourceCallback; break;
					default: return;
				}
				if (callback == null) return;
				if (userdata == null) userdata = m_userData;
				window = JSObject.getWindow(this);
			}
			window.call(callback, new Object[] { userdata });
		} catch (Exception e) { e.printStackTrace(); }
	}
	
	/**
	 * Invokes a user-defined Javascript callback when playback starts.
	 */
	void onPlay() {
		//synchronized(m_lock) {
			m_descCur = new SpcDesc(m_archive.current());
			m_descNext = m_archive.hasNext() ?
					new SpcDesc(m_archive.peek()) : null;
			m_descPrev = m_archive.hasPrevious() ?
					new SpcDesc(m_archive.peekBack()) : null;
		//}
		
		doCallback(CALLBACK_ONPLAY, null);
	}
	
	/**
	 * Invokes a user-defined Javascript callback when pausing.
	 */
	void onPause() {
		doCallback(CALLBACK_ONPAUSE, null);
	}
	
	// User-induced stop? If so, then onStop will always result in the onStop
	// callback getting called.
	private AtomicBoolean m_userStop = new AtomicBoolean(false);
	/**
	 * Invokes a user-defined Javascript callback when playback stops.
	 */
	void onStop() {
		if (m_autoAdvance && m_archive != null &&
			!m_userStop.getAndSet(false) && m_archive.hasNext()){
				synchronized(m_archLock) {
					// Auto advance?
					if (m_archive != null && m_archive.hasNext()) {
						// blasted deadlocking
						SwingUtilities.invokeLater(new Runnable() {
							@Override public final void run() {
								playNextTrack();
							}
						});
						return;
					}
				}
		}
		// Nope, not auto advance.
		Object userdata;
		synchronized(m_lock) {
			userdata = m_userData;
			m_userData = null;
			m_loadedUrl = null;
			m_archive = null;
			m_descCur = m_descNext = m_descPrev = m_descFirst = m_descLast=null;
		}
		doCallback(CALLBACK_ONSTOP, userdata);
	}
	
	/**
	 * Invokes a user-defined Javascript callback when a call to playFile fails.
	 */
	void onPlayFailure(Object userdata) {
		doCallback(CALLBACK_ONPLAYFAIL, userdata);
	}
	
	/**
	 * Invokes a user-defined Javascript callback when a new source is selected.
	 */
	void onNewSource() {
		doCallback(CALLBACK_ONNEWSOURCE, null);
	}
	
	/**
	 * Plays a file. This method should be called from a privileged thread.
	 * 
	 * @param url The URL to play from. This can be relative to the webpage
	 * the applet is on.
	 * @param title If {@code url} points to a Zip file that contains more than
	 * one SPC file, then a dialog will be displayed showing all of the SPC
	 * files in the archive. The root node will be given this title.
	 * @param userdata Caller-defined data which will be passed to the callback
	 * functions when they are called.
	 */
	void doPlayFile(String url, String title, Object userdata, int t) {
		// Stop the previous applet from playing if we're supposed to.
		if (m_tabFriendlyMode) {
			synchronized(s_globalLock) {
				if (s_lastPlayer != null && s_lastPlayer != this) {
					s_lastPlayer.doStopFile();
				}
				s_lastPlayer = this;
			}
		}
		synchronized(m_lock) {
			String curUrl = m_loadedUrl;
			Object a = curUrl;
			
			try {
				m_loadedUrl = null;
				
				synchronized(m_playerLock) {
				if (!url.equals(curUrl)) {
					// Play a new file.
					doStopFileImpl();

					// If url is relative, make it absolute.
					URL absUrl = new URL(this.getDocumentBase(), url);
					a = absUrl;
					
					NavigableMapIterator<String, byte[]> iter;
					iter = DataHelper.readURL(absUrl, this, title, m_cache,
								t >= 0 || (m_autoAdvance && m_autoSelect), t);
					if (iter == null) {
						onPlayFailure(userdata);
						return;
					}
					m_player.setFile(iter.current().getValue(), false); // already stopped
					// New source URL
					m_descCur = iter.hasItem(iter.position()) ?
										new SpcDesc(iter.current()) : null;
					
					m_descNext = iter.hasNext() ?
							new SpcDesc(iter.peek()) : null;
					m_descPrev = iter.hasPrevious() ?
							new SpcDesc(iter.peekBack()) : null;
					
					m_descFirst = new SpcDesc(iter.peekFirst());
					m_descLast = new SpcDesc(iter.peekLast());
					m_archive = iter;
					
					onNewSource();
				}
				
				m_userData = userdata;
				m_player.play();
				} // m_playerLock
				m_loadedUrl = url;
			} catch (Exception e) {
				m_userData = null;
				onPlayFailure(userdata);
				if (e instanceof FileNotFoundException) {
					System.err.printf("URL not found: \"%s\"%n", a);
					JOptionPane.showMessageDialog(this,
							String.format("URL not found.%n%s", a), "ImoSPC",
							JOptionPane.ERROR_MESSAGE);
				} else {
					e.printStackTrace();
					JOptionPane.showMessageDialog(this, e.getMessage(),
										"ImoSPC", JOptionPane.ERROR_MESSAGE);
				}
			}
		}
	}
	
	/**
	 * Pauses playback. This function should be called from a privileged thread.
	 */
	void doPauseFile() {
		synchronized(m_playerLock) {
			try {
				if (m_player.pause())
					onPause(); // Not called by SpcPlayerImpl.
			}
			catch (Exception e) {
				e.printStackTrace();
				JOptionPane.showMessageDialog(this, e.getMessage(), "ImoSPC",
											JOptionPane.ERROR_MESSAGE);
			}
		}
	}
	
	// Implementation called by doStopFile() and stop()/destroy().
	private void doStopFileImpl() {
		try {
			m_userStop.set(true);
			m_player.stop();
		} catch (Exception e) {
			e.printStackTrace();
			JOptionPane.showMessageDialog(this, e.getMessage(), "ImoSPC",
										JOptionPane.ERROR_MESSAGE);
		} finally {
			m_userStop.set(false);
		}
		
		m_loadedUrl = null;
	}
	
	/**
	 * Stops playback. This function should be called from a privileged thread.
	 */
	void doStopFile() {
		synchronized(m_playerLock) {
			doStopFileImpl();
		}
	}

	// Since playFile is not reentrant, drop all calls to it that are made
	// before it ends.
	private final java.util.concurrent.atomic.AtomicBoolean m_isInPlayFile =
		new AtomicBoolean(false);
	/**
	 * Plays a file. This method should be called from a non-privileged thread;
	 * call {@code doPlayFile()} from a privileged thread.
	 * 
	 * All calls to playFile() while another call to playFile() is pending will
	 * be dropped.
	 * 
	 * @param url The URL to play from. This can be relative to the webpage
	 * the applet is on.
	 * @param title If {@code url} points to a Zip file that contains more than
	 * one SPC file, then a dialog will be displayed showing all of the SPC
	 * files in the archive. The root node will be given this title.
	 * @param userdata Caller-defined data which will be passed to the callback
	 * functions when they are called.
	 * @param track The index of a track that should be played; if this value is
	 * negative, then the default behaviour will be used if the specified URL
	 * leads to a Zip archive containing more than one SPC file.
	 */
	public void playFile(final String url, final String title,
						 final Object userdata, final int track) {
		// Don't let playFile() be called multiple times in rapid succession.
		if (m_isInPlayFile.getAndSet(true)) {
			System.err.printf("Dropped call: playFile(\"%s\", \"%s\", %s);%n", url, title, userdata);
			return;
		}
		
		try {
			SwingUtilities.invokeLater(new Runnable() {
				@Override
				public final void run() {
					try {
						AccessController.doPrivileged(new PrivilegedAction<Object>() {
							public Object run() {
								doPlayFile(url, title, userdata, track);
								return null;
							}
						});
					} catch (Exception e) { onPlayFailure(userdata); }
					// Always release the lock after calling doPlayFile.
					finally { m_isInPlayFile.set(false); }
				}
			});
		} catch (Exception e) {
			// The operation failed. Release the lock.
			m_isInPlayFile.set(false);
			e.printStackTrace();
			onPlayFailure(userdata);
			JOptionPane.showMessageDialog(this, e.getMessage(), "ImoSPC",
										JOptionPane.ERROR_MESSAGE);
		}
	}
	/**
	 * Plays a file. This method should be called from a non-privileged thread;
	 * call {@code doPlayFile()} from a privileged thread.
	 * 
	 * All calls to playFile() while another call to playFile() is pending will
	 * be dropped.
	 * 
	 * @param url The URL to play from. This can be relative to the webpage
	 * the applet is on.
	 * @param title If {@code url} points to a Zip file that contains more than
	 * one SPC file, then a dialog will be displayed showing all of the SPC
	 * files in the archive. The root node will be given this title.
	 * @param userdata Caller-defined data which will be passed to the callback
	 * functions when they are called.
	 */
	public void playFile(String url, String title, Object userdata) {
		playFile(url, title, userdata, -1);
	}
	
	/**
	 * Pauses playback. This method should be called from a non-privileged
	 * thread; call {@code doPauseFile()} from a privileged thread.
	 */
	public synchronized void pauseFile() {
		try {
			SwingUtilities.invokeAndWait(new Runnable() {
				@Override
				public void run() {
					doPauseFile();
				}
			});
		}
		catch (Exception e) {
			e.printStackTrace();
			JOptionPane.showMessageDialog(this, e.getMessage(), "ImoSPC",
										JOptionPane.ERROR_MESSAGE);
		}
	}
	
	/**
	 * Stops playback. This method should be called from a non-privileged
	 * thread; call {@code doStopFile()} from a privileged thread.
	 */
	public void stopFile() {
		try {
			SwingUtilities.invokeAndWait(new Runnable() {
				@Override
				public void run() {
					doStopFile();
				}
			});
		} catch (Exception e) {
			e.printStackTrace();
			JOptionPane.showMessageDialog(this, e.getMessage(), "ImoSPC",
										JOptionPane.ERROR_MESSAGE);
		}
	}
	
	/**
	 * Checks whether ImoSPC is still playing.
	 * 
	 * @return {@code true} if an SPC file is playing; otherwise, {@code false}.
	 */
	public boolean isPlaying() {
		return m_player.isPlaying();
	}
	
	/**
	 * Checks if a file is loaded.
	 * 
	 * @return {@code true} if a file has been loaded; otherwise, {@code false}.
	 */
	public boolean hasFile() {
		return m_archive != null;
	}
	
	/**
	 * Checks if there is a next track in the archive.
	 * 
	 * @return {@code true} if there is another track to play; otherwise,
	 * {@code false}.
	 */
	public boolean hasNextTrack() {
		if (m_archive == null) return false;
		return m_archive.hasNext();
	}
	
	/**
	 * Checks if there is a previous track in the archive.
	 * 
	 * @return {@code true} if there is a previous track to play; otherwise,
	 * {@code false}.
	 */
	public boolean hasPreviousTrack() {
		if (m_archive == null) return false;
		return m_archive.hasPrevious();
	}
	
	/**
	 * Gets the currently playing track.
	 * 
	 * @return the index of the currently playing track, or {@code -1} if no
	 * file is loaded.
	 */
	public int curTrack() {
		if (m_archive == null) return -1;
		return m_archive.position();
	}
	
	/**
	 * Gets the number of tracks in the archive.
	 * 
	 * @return the number of tracks in the archive, or {@code -1} if no file is
	 * loaded.
	 */
	public int numTracks() {
		if (m_archive == null) return -1;
		return m_archive.count();
	}
	
	// Simplified play function.
	private void playImpl(byte[] spc) {
		try {
			synchronized(m_playerLock) {
				m_player.setFile(spc, false);
				m_player.play();
			}
		} catch (Exception e) {
			e.printStackTrace();
			JOptionPane.showMessageDialog(this, e.getMessage(), "ImoSPC",
										JOptionPane.ERROR_MESSAGE);
		}
	}
	
	// Make sure m_archive is set.
	private boolean archValidate() {
		if (m_archive == null) {
			String message = "No archive is loaded.";
			System.err.println(message);
			JOptionPane.showMessageDialog(this, message, "ImoSPC",
										JOptionPane.ERROR_MESSAGE);
			return false;
		}
		return true;
	}
	
	/**
	 * Restarts the current SPC track.
	 */
	public void restartTrack() {
		if (!archValidate()) return;
		byte[] c;
		synchronized(m_archLock) {
			if (!archValidate()) return;
			c = m_archive.current().getValue();
		}
		playImpl(c);
	}
	
	/**
	 * Plays the next SPC in the archive.
	 */
	public void playNextTrack() {
		if (!archValidate()) return;
		byte[] c;
		synchronized(m_archLock) {
			if (!archValidate()) return;
			if (!m_archive.hasNext()) {
				String message = "There are no more tracks after the current " +
								 "track.";
				System.err.println(message);
				JOptionPane.showMessageDialog(this, message, "ImoSPC",
											JOptionPane.ERROR_MESSAGE);
				return;
			}
			c = m_archive.next().getValue();
		}
		playImpl(c);
	}
	
	/**
	 * Plays the previous SPC in the archive.
	 */
	public void playPreviousTrack() {
		if (!archValidate()) return;
		byte[] c;
		synchronized(m_archLock) {
			if (!archValidate()) return;
			if (!m_archive.hasPrevious()) {
				String message = "There are no more tracks before the current" +
								 " track.";
				System.err.println(message);
				JOptionPane.showMessageDialog(this, message, "ImoSPC",
											JOptionPane.ERROR_MESSAGE);
				return;
			}
			c = m_archive.previous().getValue();
		}
		playImpl(c);
	}
	
	/**
	 * Plays the first SPC in the archive.
	 */
	public void playFirstTrack() {
		if (!archValidate()) return;
		byte[] c;
		synchronized(m_archLock) {
			if (!archValidate()) return;
			c = m_archive.first().getValue();
		}
		playImpl(c);
	}
	
	/**
	 * Plays the last SPC in the archive.
	 */
	public void playLastTrack() {
		if (!archValidate()) return;
		byte[] c;
		synchronized(m_archLock) {
			if (!archValidate()) return;
			c = m_archive.last().getValue();
		}
		playImpl(c);
	}
	
	/**
	 * Gets information about the currently playing SPC file.
	 * 
	 * @return an instance of {@link SpcDesc} containing information about the
	 * currently playing SPC file.
	 */
	public SpcDesc nowPlaying() {
		return m_descCur;
	}
	/**
	 * Gets information about the next SPC file.
	 * 
	 * @return an instance of {@link SpcDesc} containing information about the
	 * next SPC file.
	 */
	public SpcDesc next() {
		return m_descNext;
	}
	/**
	 * Gets information about the previous SPC file.
	 * 
	 * @return an instance of {@link SpcDesc} containing information about the
	 * previous SPC file.
	 */
	public SpcDesc prev() {
		return m_descPrev;
	}
	/**
	 * Gets information about the first SPC file in the archive.
	 * 
	 * @return an instance of {@link SpcDesc} containing information about the
	 * first SPC file in the archive.
	 */
	public SpcDesc first() {
		return m_descFirst;
	}
	/**
	 * Gets information about the last SPC file in the archive.
	 * 
	 * @return an instance of {@link SpcDesc} containing information about the
	 * last SPC file in the archive.
	 */
	public SpcDesc last() {
		return m_descLast;
	}
	
	/**
	 * Checks if Auto Advance is enabled.
	 * 
	 * @return {@code true} if Auto Advance is enabled; otherwise,
	 * {@code false}.
	 */
	public boolean isAutoAdvanceEnabled() { return m_autoAdvance; }
	/**
	 * Checks if Auto Select is enabled.
	 * 
	 * @return {@code true} if Auto Select is enabled; otherwise, {@code false}.
	 */
	public boolean isAutoSelectEnabled() { return m_autoSelect; }

	/**
	 * Enables or disables Auto Advance.
	 * 
	 * @param on {@code true} enables Auto Advance, while {@code false} disables
	 * it.
	 */
	public void enableAutoAdvance(boolean on) { m_autoAdvance = on; }
	/**
	 * Enables or disables Auto Select.
	 * 
	 * @param on {@code true} enables Auto Select, while {@code false} disables
	 * it.
	 */
	public void enableAutoSelect(boolean on) { m_autoSelect = on; }
	
	/** Clears the SPC cache attached to this particular instance of ImoSPC. */
	public void clearLocalCache() { m_cache.clear(); }
	/** Clears the global SPC cache, which affects the entire JVM. */
	public void clearGlobalCache() { DataHelper.clearGlobalCache(); }
	/** Clears both the global and local SPC caches. */
	public void clearAllCaches() {
		m_cache.clear();
		DataHelper.clearGlobalCache();
	}
}
